32 Raycaster 射线检测与设备交互
Raycaster 射线检测与设备交互
关联:索引
要解决的问题
- 工业 3D 场景里,用户点击“机械臂/传送带/工位”时,系统怎么知道用户点的是哪个模型?如果模型很复杂(很多网格/层级),拾取还能稳定吗?
- 浏览器里的鼠标事件是 2D(屏幕坐标),Three.js 的模型是 3D(世界坐标)。这两套坐标系统如何建立“可计算”的关联?
- hover 与 click 的交互体验差异是什么?什么时候只做 click,什么时候必须加 hover 作为“预提示”?
- 选中高亮到底改什么更合理:改颜色、改透明度、加描边、加发光?如何做到“选中明显但不破坏材质真实感”?
- 为什么一做射线检测就卡?真正的性能瓶颈来自哪里:检测频率、可检测对象数量、递归层级,还是渲染本身?
- 我如何用一套“场景化测试清单”验证:不同设备都能被选中、信息展示准确、误触发可控、响应流畅?
本讲定位(与前置衔接,避免重复)
- 已具备:Three.js 最小渲染闭环(Scene/Camera/Renderer/Mesh)、Vue3 + TS 挂载与卸载 dispose、基础几何体/材质、(可选)OrbitControls 导航与视角限制(参考 Three.js 入门、几何体与材质、OrbitControls)。
- 本讲不展开:复杂后处理描边(OutlinePass)、GPU picking、复杂 UI 框架接入、模型层级的大规模资产管理(后续工程化扩展)。
章节内容(本讲核心)
- Raycaster 射线检测的工作原理(从相机发射射线,与物体求交)
- 射线检测与鼠标事件关联(鼠标点击、hover 预提示)
- 工业场景设备选中功能实现(点击模型高亮)
- 选中设备后信息面板展示(设备名称、运行状态)
- 射线检测性能优化(射线范围限制、检测频率控制、可选对象裁剪)
- 场景化测试(多设备选中、信息准确性、误触发与流畅性)
环境与先修(默认沿用 Three.js 工程)
先修要求:
- 已有 Vue3 + Vite + TypeScript 工程,并已安装 Three.js。
- 已能渲染基础场景,最好已接入 OrbitControls(非强制,但推荐)。
如你需要补装依赖(仅在未安装时执行):
npm i three
npm i -D @types/three
解释:
three:Three.js 核心库,Raycaster 属于核心类,无需额外安装。@types/three:TypeScript 类型定义,避免类型提示缺失或构建期报错(老工程更常见)。
工业场景的拾取(Picking)不是“炫技交互”,而是数据可视化的入口。最低标准可以总结为三句话:
- 点得准:用户点到设备,就是该设备(误触发要可控)。
- 反应快:点击后高亮必须即时(可感知延迟会破坏信任)。
- 信息对:选中的设备信息必须准确(名称/状态/告警等不能乱跳)。
Raycaster 解决的问题可以拆成四步:
- 获取鼠标在画布上的位置(屏幕坐标,单位是像素)
- 把屏幕坐标转换为 NDC(Normalized Device Coordinates,范围是 [-1, 1])
- 从相机发射射线(
raycaster.setFromCamera(ndc, camera)) - 与场景中“可选对象集合”求交(
intersectObjects),取最近命中作为选中目标
关键结论(背下来就能排错):
- NDC 的 X:左 -1,右 +1;NDC 的 Y:下 -1,上 +1(注意屏幕坐标 Y 方向需要反转)。
intersectObjects返回按距离排序,intersects[0]通常是“最前面被点到”的物体。
目标:
- 在“工业分拣产线雏形场景”中放置至少 2 个设备(几何体代替也可)。
- 点击设备:选中并高亮;点击空白:取消选中。
1) 可复制运行:Raycaster 点击选中组件(Vue3 + TS)
下面示例用几何体搭建“机械臂 + 传送带”两个可选对象,并把设备元信息放进 userData,用于后续信息面板展示。
建议落地文件路径(班级统一口径,便于排错):
src/
└─ components/
└─ RaycasterDevicePickLab.vue
解释:
- 文件名中包含
Raycaster + DevicePick + Lab,后续扩展 hover、信息面板与性能优化时不容易混淆。
<template>
<div class="page">
<!-- Three.js 渲染容器:renderer.domElement 会 append 到这里 -->
<div ref="containerRef" class="three"></div>
<section class="panel">
<h2>设备信息</h2>
<div v-if="selectedDevice">
<div class="row">
<span class="label">名称</span>
<span class="value">{{ selectedDevice.name }}</span>
</div>
<div class="row">
<span class="label">状态</span>
<span class="value">{{ selectedDevice.status }}</span>
</div>
</div>
<div v-else class="hint">点击 3D 设备以查看信息</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
type DeviceStatus = 'RUNNING' | 'IDLE' | 'ALARM';
type DeviceMeta = { deviceId: string; deviceName: string; deviceStatus: DeviceStatus };
// 容器 DOM:用于计算鼠标坐标(getBoundingClientRect)与挂载 canvas
const containerRef = ref<HTMLDivElement | null>(null);
// Three.js 核心对象(在 onMounted 创建,在 onBeforeUnmount 释放)
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;
let controls: OrbitControls | null = null;
let rafId: number | null = null;
let resizeObserver: ResizeObserver | null = null;
let onPointerDown: ((e: PointerEvent) => void) | null = null;
let onPointerUp: ((e: PointerEvent) => void) | null = null;
// Raycaster:负责从相机发射射线并与可选对象求交
const raycaster = new THREE.Raycaster();
// 鼠标 NDC(Normalized Device Coordinates):范围 [-1, 1]
const mouseNdc = new THREE.Vector2();
// 可选对象集合:只对它们做求交(避免遍历整个 scene)
const selectableMeshes: THREE.Mesh[] = [];
// 需要释放资源的对象集合:卸载时统一 dispose(几何体/材质/GPU 资源)
const disposables: THREE.Object3D[] = [];
// 选中对象:用于高亮与信息面板展示
const selectedObject = ref<THREE.Object3D | null>(null);
// 记录选中前的原材质:用于取消选中时恢复
let selectedOriginalMaterial: THREE.Material | THREE.Material[] | null = null;
// 选中高亮材质:复用一份,避免频繁 new Material 造成 GC/性能波动
const selectedHighlightMaterial = new THREE.MeshStandardMaterial({
color: 0x22c55e,
emissive: 0x14532d,
emissiveIntensity: 0.9,
transparent: true,
opacity: 0.92,
});
// 从 selectedObject.userData 读出业务信息(面板展示用)
const selectedDevice = computed(() => {
const obj = selectedObject.value;
if (!obj) return null;
const meta = obj.userData as Partial<DeviceMeta>;
if (!meta.deviceName || !meta.deviceStatus) return null;
return { name: meta.deviceName, status: meta.deviceStatus };
});
function resize() {
const container = containerRef.value;
if (!container || !renderer || !camera) return;
const width = container.clientWidth;
const height = container.clientHeight;
if (width <= 0 || height <= 0) return;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function animate() {
if (!renderer || !scene || !camera) return;
// 开启 enableDamping 后必须每帧 update,否则阻尼无效
controls?.update();
renderer.render(scene, camera);
rafId = requestAnimationFrame(animate);
}
function setHighlight(mesh: THREE.Mesh, enabled: boolean) {
if (enabled) {
mesh.material = selectedHighlightMaterial;
} else if (selectedOriginalMaterial) {
mesh.material = selectedOriginalMaterial;
}
}
function clearSelection() {
const current = selectedObject.value;
if (current && current instanceof THREE.Mesh) {
setHighlight(current, false);
}
selectedObject.value = null;
selectedOriginalMaterial = null;
}
// 拾取入口:将 pointer 坐标换算为 NDC,发射射线并求交
function pickObjectAt(clientX: number, clientY: number) {
const container = containerRef.value;
if (!container || !camera) return;
// 用容器 rect 做换算(避免 canvas 非全屏时坐标计算错误)
const rect = container.getBoundingClientRect();
const inside =
clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
if (!inside) return;
// 像素坐标 -> NDC:x 映射到 [-1, 1];y 需要翻转(屏幕 y 向下,NDC y 向上)
mouseNdc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
mouseNdc.y = -(((clientY - rect.top) / rect.height) * 2 - 1);
// 从相机穿过 mouseNdc 发射射线
raycaster.setFromCamera(mouseNdc, camera);
// 只检测 selectableMeshes(性能与误触发控制的关键)
const intersects = raycaster.intersectObjects(selectableMeshes, false);
const hit = intersects[0]?.object ?? null;
// 点击空白:取消选中
if (!hit) {
clearSelection();
return;
}
// 点击同一对象:不重复处理(避免材质反复替换)
if (selectedObject.value === hit) return;
clearSelection();
selectedObject.value = hit;
if (hit instanceof THREE.Mesh) {
// 保存原材质:用于取消选中恢复
selectedOriginalMaterial = hit.material;
setHighlight(hit, true);
}
}
onMounted(() => {
const container = containerRef.value;
if (!container) throw new Error('Three container not found');
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1220);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
container.appendChild(renderer.domElement);
const width = container.clientWidth || 1;
const height = container.clientHeight || 1;
camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
camera.position.set(8, 5, 10);
camera.lookAt(0, 0, 0);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.target.set(0, 0.8, 0);
controls.update();
const gridHelper = new THREE.GridHelper(30, 30);
const axesHelper = new THREE.AxesHelper(2);
scene.add(gridHelper);
scene.add(axesHelper);
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
disposables.push(gridHelper, axesHelper);
const light = new THREE.DirectionalLight(0xffffff, 1.2);
light.position.set(6, 10, 4);
scene.add(light);
disposables.push(light);
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(30, 30),
new THREE.MeshStandardMaterial({ color: 0x0f172a, roughness: 1 })
);
floor.rotation.x = -Math.PI / 2;
scene.add(floor);
disposables.push(floor);
const arm = new THREE.Mesh(
new THREE.CylinderGeometry(0.35, 0.35, 2.4, 24),
new THREE.MeshStandardMaterial({ color: 0x64748b, metalness: 0.6, roughness: 0.35 })
);
arm.position.set(-3, 1.2, 0);
const armMeta: DeviceMeta = { deviceId: 'arm-01', deviceName: '机械臂-01', deviceStatus: 'RUNNING' };
arm.userData = armMeta;
scene.add(arm);
selectableMeshes.push(arm);
disposables.push(arm);
const belt = new THREE.Mesh(
new THREE.BoxGeometry(5, 0.35, 1.2),
new THREE.MeshStandardMaterial({ color: 0x334155, metalness: 0.2, roughness: 0.7 })
);
belt.position.set(3, 0.2, 0);
const beltMeta: DeviceMeta = { deviceId: 'belt-01', deviceName: '传送带-01', deviceStatus: 'IDLE' };
belt.userData = beltMeta;
scene.add(belt);
selectableMeshes.push(belt);
disposables.push(belt);
resize();
resizeObserver = new ResizeObserver(() => resize());
resizeObserver.observe(container);
const pointerDown = { x: 0, y: 0 };
onPointerDown = (e: PointerEvent) => {
pointerDown.x = e.clientX;
pointerDown.y = e.clientY;
};
// pointerup 时判断位移:位移过大视为 OrbitControls 拖拽导航,不触发点击拾取
onPointerUp = (e: PointerEvent) => {
const dx = e.clientX - pointerDown.x;
const dy = e.clientY - pointerDown.y;
const moved = Math.hypot(dx, dy);
if (moved > 3) return;
pickObjectAt(e.clientX, e.clientY);
};
renderer.domElement.addEventListener('pointerdown', onPointerDown);
renderer.domElement.addEventListener('pointerup', onPointerUp);
animate();
});
onBeforeUnmount(() => {
if (renderer?.domElement && onPointerDown) renderer.domElement.removeEventListener('pointerdown', onPointerDown);
if (renderer?.domElement && onPointerUp) renderer.domElement.removeEventListener('pointerup', onPointerUp);
if (rafId !== null) cancelAnimationFrame(rafId);
resizeObserver?.disconnect();
clearSelection();
controls?.dispose();
if (scene) {
for (const obj of disposables) {
scene.remove(obj);
const resourceOwner = obj as unknown as { geometry?: { dispose?: () => void }; material?: THREE.Material | THREE.Material[] };
if (resourceOwner.geometry && typeof resourceOwner.geometry.dispose === 'function') {
resourceOwner.geometry.dispose();
}
const m = resourceOwner.material;
if (Array.isArray(m)) {
for (const mi of m) {
if (mi !== selectedHighlightMaterial) mi.dispose();
}
} else if (m && m !== selectedHighlightMaterial) {
m.dispose();
}
}
}
selectableMeshes.length = 0;
disposables.length = 0;
selectedHighlightMaterial.dispose();
renderer?.dispose();
if (renderer?.domElement && renderer.domElement.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement);
}
selectedObject.value = null;
selectedOriginalMaterial = null;
controls = null;
camera = null;
scene = null;
renderer = null;
resizeObserver = null;
rafId = null;
onPointerDown = null;
onPointerUp = null;
});
</script>
<style scoped>
.page {
display: grid;
grid-template-columns: 1fr 320px;
gap: 12px;
padding: 12px;
background: #0b1220;
min-height: 100vh;
color: #e5e7eb;
}
.three {
width: 100%;
height: calc(100vh - 24px);
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 10px;
overflow: hidden;
}
.panel {
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 10px;
padding: 12px;
background: rgba(15, 23, 42, 0.78);
height: fit-content;
}
.panel h2 {
margin: 0 0 10px;
font-size: 16px;
}
.row {
display: grid;
grid-template-columns: 56px 1fr;
align-items: center;
gap: 10px;
padding: 8px 0;
border-top: 1px solid rgba(148, 163, 184, 0.12);
}
.label {
color: rgba(226, 232, 240, 0.75);
}
.value {
font-weight: 700;
}
.hint {
color: rgba(226, 232, 240, 0.7);
padding: 10px 0;
}
</style>
把组件挂载到页面(最小方式:App 直接渲染):
src/App.vue(核心片段):
<template>
<RaycasterDevicePickLab />
</template>
<script setup lang="ts">
import RaycasterDevicePickLab from './components/RaycasterDevicePickLab.vue';
</script>
解释:
- 这是最不容易走丢的挂载方式:打开页面就能看到实验,不依赖路由配置。
解释(把 Raycaster 接入“鼠标点击→选中状态”):
- 建立射线检测核心对象:
const raycaster = new THREE.Raycaster():负责“从相机发射射线并求交”。const mouseNdc = new THREE.Vector2():保存鼠标在 NDC 空间的坐标。- 鼠标坐标转换(关键易错点):
getBoundingClientRect():拿到容器在页面中的位置与尺寸,确保计算的是“相对于 canvas 容器”的坐标。mouseNdc.x = ((x / width) * 2 - 1):把像素映射到 [-1, 1]。mouseNdc.y需要取负号:因为屏幕坐标 y 向下增长,而 NDC y 向上增长。- 发射射线与求交:
raycaster.setFromCamera(mouseNdc, camera):生成“从相机出发、穿过鼠标点”的射线。raycaster.intersectObjects(selectableMeshes, false):只对“可选对象集合”检测,避免整场景遍历导致卡顿;第二个参数false表示不递归检测子物体(几何体示例足够用)。- 事件绑定(更推荐的写法):
- 将拾取事件绑定到
renderer.domElement,避免页面其它区域点击也触发计算。 - 用
pointerdown/pointerup+ 位移阈值(示例为 3px)区分“拖拽导航”和“真实点击”,降低 OrbitControls 拖拽误触发。 - 选中状态机(最小可用):
- 命中:先清理上一次选中,再设置新的
selectedObject,再高亮。 - 未命中:清空选中(点击空白取消)。
- 点击同一对象:不重复处理(避免材质频繁替换)。
- 高亮策略(本讲采用“替换材质”):
- 选中时把
mesh.material替换为高亮材质(更直观)。 - 取消选中时恢复原材质(用
selectedOriginalMaterial记录)。
hover 的价值:
- 降低误操作:用户在点击前就能确认“即将选中谁”。
- 提升效率:在密集设备场景里,hover 提示比反复点击更快。
hover 的风险:
-
频率高:鼠标移动事件触发非常频繁,射线检测容易成为性能热点。
-
状态冲突:hover 高亮与选中高亮要区分,否则会出现“选中被 hover 覆盖”。
-
做对状态机(hover 与 selected 分层)。
-
做好限频与裁剪(性能稳定)。
推荐状态口径(工程里更不容易乱):
hoveredObject:鼠标当前指向的对象(随移动变化)。selectedObject:用户点击确认的对象(相对稳定,直到再次点击或取消)。
高亮优先级建议:
selected高亮优先级最高(永远覆盖 hover)。hover只在“当前未选中该对象”时显示预提示。
本讲的信息面板只展示两项:名称、运行状态。但建议你从一开始就让数据结构可扩展,例如:
deviceId:后续用于与后端/ROS2 设备 ID 对齐。deviceStatus:后续可以扩展为枚举 + 颜色映射(RUNNING/IDLE/ALARM)。metrics:后续可以挂载温度/电流/节拍等实时指标(下一阶段接 WebSocket/ROS2 时会用到)。
最小扩展示例(无需改 3D 逻辑,只改面板展示):
type DeviceMeta = {
deviceId: string;
deviceName: string;
deviceStatus: 'RUNNING' | 'IDLE' | 'ALARM';
temperatureC?: number;
};
解释:
userData本质是“把 3D 对象与业务数据绑定”的入口。- 工业项目里建议把
userData当作“最小可视化元数据”,更复杂的数据仍建议放在业务层(Pinia/store)统一管理。
性能优化优先级从高到低建议是:
- 限对象:只检测“可选对象集合”,不要对整个 scene 求交
- 限频率:hover 必须限频;click 可以每次点击求交一次
- 限范围:缩短射线检测范围(near/far),减少无意义命中
- 限递归:能不递归就不递归;复杂模型可用“设备根节点”统一拾取
1) 射线范围限制(推荐)
raycaster.near = 0.2;
raycaster.far = 80;
解释:
near/far会把求交限制在“射线的一段距离区间”内。- 工业产线场景通常不需要点到很远的对象;设定
far能减少求交成本,也降低误触发(点到背景远处)。
2) 检测频率控制(hover 必做)
hoverRaycast.minIntervalMs = 40;
解释:
- 40ms 的节流意味着 1 秒最多 25 次求交,体验通常仍然顺滑。
- 如果设备数量很多,先把频率降下来,再考虑进一步裁剪对象集合。
3) “可选对象集合”的组织策略(工程化建议)
当你从几何体过渡到 GLTF 模型后,常见问题是:点击命中的是模型内部的某个子网格,但业务上你想选中“整台设备”。推荐两种策略:
- 策略 A(简单):为每台设备准备一个“可拾取外壳/代理网格(Proxy Mesh)”,只把代理网格放进
selectableMeshes。 - 策略 B(通用):命中子网格后,沿着
object.parent向上找,直到找到带userData.deviceId的“设备根节点”。
策略 B 的最小实现:
function findDeviceRoot(obj: THREE.Object3D): THREE.Object3D | null {
let current: THREE.Object3D | null = obj;
while (current) {
const meta = current.userData as Partial<DeviceMeta>;
if (meta.deviceId) return current;
current = current.parent;
}
return null;
}
解释:
- 这是“从命中子网格归一化到设备根节点”的通用做法。
- 你需要在加载模型/创建设备时,把
deviceId/deviceName/deviceStatus挂到设备根节点的userData。
- 多设备选中:机械臂/传送带/工位(至少 2 个)都能被点击选中,且不会串台。
- hover 预提示:鼠标移入设备有预提示(高亮或指针),移出能恢复。
- 信息准确性:信息面板名称/状态与设备绑定一致,连续切换不错误。
- 误触发控制:点击地面/网格不选中设备,或不会影响当前选中。
- 流畅性:快速移动鼠标与连续点击时无明显卡顿,交互响应无明显延迟。
项目工坊:工业分拣产线设备选中 + 信息面板 + 性能优化
实现目标:
-
鼠标点击机械臂、传送带等设备:触发模型高亮。
-
弹出信息面板:展示设备基础信息(设备名称、运行状态)。
-
优化交互响应速度:在设备数量增加时仍保持流畅。
-
至少 2 个设备可稳定选中,高亮与信息正确。
-
hover 预提示不抖动,click 不误触发。
-
快速操作不卡顿(无明显延迟、无误触发)。
作业(布置)
- 完成 3D 场景中至少 2 个设备的射线检测选中功能,实现选中高亮与信息面板展示。
- 优化射线检测性能,确保点击响应流畅(无延迟、无误触发)。
参考与延伸
- Three.js Raycaster 官方文档:https://threejs.org/docs/#api/en/core/Raycaster
- Three.js Vector2 官方文档:https://threejs.org/docs/#api/en/math/Vector2
- OrbitControls(examples)说明:https://threejs.org/docs/#examples/en/controls/OrbitControls
- Pointer Events(MDN):https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events